久々にfrontendのおべんきょ Svelteで掲示板
最近おべんきょが全然できてないなーと感じていたので、
改めてSvelteに入門しようかと!!
良い記事を見つけたので、こちらにしたがって再入門だー
ひとまず開発環境の更新・構築
ふだんバックエンドの開発しかしないため、手元の環境のnodejsはかなり古い。
ってことで、ひとまず最新安定バージョンにアップデートしちゃいます。
$ node -v
bash: node: command not found
そもそも入ってない!!
ダウンロード・インストールから
2023/06/29時点での安定版は18.16.1
$ node -v
v18.16.1
$ npm -v
9.5.1
結果的に最新のnodejsを入れられたのでOK!!
プロジェクトを作る
$ npm create svelte@latest board-app
Need to install the following packages:
create-svelte@5.0.2
Ok to proceed? (y) y
┌ Welcome to SvelteKit!
│
◇ Which Svelte app template?
│ Skeleton project
│
◇ Add type checking with TypeScript?
│ Yes, using TypeScript syntax
│
◇ Select additional options (use arrow keys/space bar)
│ Add ESLint for code linting
│
└ Your project is ready!
✔ Typescript
Inside Svelte components, use <script lang="ts">
✔ ESLint
Install community-maintained integrations:
Next steps:
1: cd board-app
2: npm install (or pnpm install, etc)
3: git init && git add -A && git commit -m "Initial commit" (optional)
4: npm run dev -- --open
To close the dev server, hit Ctrl-C
Next stepsに書かれたとおりに立ち上げてみる
$ cd board-app/
$ npm install
added 220 packages, and audited 221 packages in 24s
43 packages are looking for funding
run npm fund for details
found 0 vulnerabilities
$ npm run dev -- --open
> board-app@0.0.1 dev
> vite dev --open
Forced re-optimization of dependencies
VITE v4.3.9 ready in 663 ms
➜ Network: use --host to expose
➜ press h to show help
SvelteKitを使ったプロジェクト作成はOK!
データベース
記事の中ではPrismaというORMを利用しているみたいです。
テスト的な開発なので、わざわざRDBサーバ入れたくないしなー、ファイルDBがいいなーとか思いながら読んでます。
ひとまず記事通りPrismaというのを使ってみようかと。
まず間違えたくないのが、PrismaはORMみたいです。
DBとの疎通をSQLじゃなくclassなどのデータを使って操作することを目的とするツールですね。
ってことはどこかしらのRDBサーバと疎通する機能は含まれているはずで、
それがファイルDBである可能性もまだある!はず!
ORMのメリットとしてはSQLを使わなくていいってところにあって、
多くの場合はマイグレーション機能が付いていて、DBの初期化やテーブル追加などもコードベースで行えちゃう。
何はともあれprismaをいれておきましょ。
$ npm install prisma --save-dev
added 2 packages, and audited 223 packages in 4s
43 packages are looking for funding
run npm fund for details
found 0 vulnerabilities
$ npx prisma init
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
warn You already have a .gitignore file. Don't forget to add .env in it to not commit any private information.
Next steps:
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.
More information in our documentation:
providerは合ったものにしてください。僕はSupabaseを使用しているのでpostgresqlとなります。
Supabaseとやらを知らないけど、postgresqlの派生か、postgresqlが裏で動いてる新しいDBかサービスか何かなのかも。
ここをSQLiteに出来ないかちらっと調べてみる。
npx prisma init --datasource-provider sqlite
initコマンドにオプションが必要なのか!!
$ npx prisma init --datasource-provider sqlite
ERROR A folder called prisma already exists in your project.
Please try again in a project that is not yet using Prisma.
プロジェクトディレクトリ直下に prisma というディレクトリがあるので、
これを削除して再度コマンドを実行
$ npx prisma init --datasource-provider sqlite
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
warn Prisma would have added DATABASE_URL but it already exists in .env
warn You already have a .gitignore file. Don't forget to add .env in it to not commit any private information.
Next steps:
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.
More information in our documentation:
prisma/schema.prisma が生成されているので中身を見てみると、
code:schema.prisma
// This is your Prisma schema file,
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
sqlite用の設定ファイルが出来ていそう!
.env ファイルも生成されているので、DBの設定をしておく。
code:.env
DATABASE_URL="file:./dev.db"
この設定によって、 prisma/dev.db というファイルが作られ、
それがDBファイルになる。
スキーマ・テーブル設計 = モデル定義
今回の掲示板アプリに必要なのは「User」「Post」「Comment」です。それぞれユーザのテーブル、投稿されたスレッドのテーブル、スレッドに対するコメントのテーブルになっています。
とのことなので、prismaの設定ファイルに各モデルを定義していく。
って言っても記事のコピペ。
主キーとか、初期値とか、依存をそう表現するのかーというのはちらっとわかるけど、
応用力はここでは身に付かなそー。
マイグレーション
schema.prisma で定義したモデルをDBに反映しちゃいましょう。
$ npx prisma migrate dev --name init
Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
SQLite database dev.db created at file:./dev.db
Applying migration 20230704003558_init
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20230704003558_init/
└─ migration.sql
Your database is now in sync with your schema.
Running generate... (Use --skip-generate to skip the generators)
added 2 packages, and audited 225 packages in 5s
43 packages are looking for funding
run npm fund for details
found 0 vulnerabilities
✔ Generated Prisma Client (4.16.2 | library) to .\node_modules\@prisma\client in 39ms
DBコネクタを用意しておく
DBの準備はできたので、あとはプログラムから呼び出すための準備だけしておく。
code:src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient()
これでDB周りの準備は終わり
sqlite3を入れておくと確認が楽
今回DBとしてsqliteを利用しているので、sqlite3のcli クライアントがあると動作確認が楽になる。
これを書いてる時点でのバージョンが3.42.0
windowsにclieを入れるので、下記の2つのzipをダウンロードしてきて適当なディレクトリに解凍しておく。
sqlite-dll-win64-x64-3420000.zip
sqlite-tools-win32-x86-3420000.zip
あとはそれぞれを解凍してファイルが並んでいるディレクトリにパスを通せばOK。
$ sqlite3 --version
3.42.0 2023-05-16 12:36:15 831d0fb2836b71c9bc51067c49fee4b8f18047814f2ff22d817d25195cf350b0
ルーティング
SvelteKitはsrc/routesがデフォルトのルートです。つまり、routes直下に配置されたページは「/」で表示されます。
ほう、ルーティングの設定ファイルがあると思ってたけど、
ファイルの位置で良い感じにルーティングを作ってくれるのか。
ひとまず記事通りにディレクトリを作ってみる
https://scrapbox.io/files/64a37e6417e06f001b7e992a.png
静的ページを表示する場合は、ディレクトリの中に +page.svelte を作ればいいみたい。
詳細画面となるdetail/[postId]の[postId]は動的なルーティングに対応します。
ディレクトリ構造でワイルドカードマッチングできるのすごいねー。
ページ作成
ユーザー登録画面
code:register/+page.svelte
<h1>ユーザー登録</h1>
<form method="post" accept-charset="?/register">
<label>名前:
<input name="name" type="text">
</label>
<label>パスワード:
<input name="password" type="password">
</label>
<button>登録する</button>
</form>
cssによるデザイン面は何もないけど、とりあえずユーザー登録ページが作られた
SvelteKitのページは「+page.ts」「+page.server.ts」によって支えられています。
「+page.ts」はクライアントサイドで実行されます。
クライアント側のロジックやユーザインタラクションの処理に用いるのが推奨されます。
「+page.server.ts」はサーバー側で実行されます。
SSRのロジックや、データベースからデータを取得する処理などを記述することが推奨されます。
DBにデータを保存することを考えたらサーバサイドにロジックが必要になる。
ってことで、 +page.server.ts を作らなくてはいけないってことねー。
bcrypt を使うので取り込んでおく
$ npm install --save bcrypt @types/bcrypt
Actionsのインポート先である./$typesはSvelteKitのコンポーネントや関数の型情報を提供しています。
なーるほ、sveltekitのお作法的なものなのね。
フレームワークを利用するときは依存先として ./$types を指定したほうがよさそう。
「register」が定義されています。これは「+page.svelte」のformタグのactionの値に対応します。
code:./src/register/+page.server.ts
export const actions: Actions = {
register: async ({request}) => {
...
}
}
actions actionsとして register に関数を追加してる。
この register が +page.svelte の formにあった action="?/register" に対応しているとのこと。
試しにユーザー登録してみて、DBにデータが入ってるか確認する。
$ sqlite3 prisma/dev.db
SQLite version 3.42.0 2023-05-16 12:36:15
Enter ".help" for usage hints.
sqlite> .tables
Comment Post User _prisma_migrations
sqlite> select * From User;
f9afed75-b07a-4a19-9708-eba276e0ed6f|テスト1|$2b$10$3ybTfgZqY9DnUARYlHIeFe4tU6PDx8106ehQvdl9W/IvpogvMAsgG|eb727cca-9af5-4b84-8740-76ce2aa0e809|1688449073746>
sqlite> .exit
uuid、名前、ハッシュ化されたパスワード、トークン が入ってる感じかな。
ちゃんとinsertされていることを確認!
正常処理の確認はOK。
異常時のメッセージを表示するために、少し手を入れる
code:register/+page.svelte
<script lang="ts">
import type { ActionData } from "./$types"
export let form: ActionData
</script>
<h1>ユーザー登録</h1>
{#if form?.message}
<p class="error">{form?.message}</p>
{/if}
<form method="post" action="?/register">
<label>名前:
<input name="name" type="text">
</label>
<label>パスワード:
<input name="password" type="password">
</label>
<button>登録する</button>
</form>
<style>
.error {
color: red;
}
</style>
scriptとしてformの取り出しと、messageがあれば表示する箇所を追加
ついでにメッセージは赤色表示で出すcssも追加されてる
ログイン画面
基本的にはユーザー登録画面と同じように、 +page.svelte で画面を作り、 +page.server.ts でサーバサイドの処理を書く。
+page.svelte に関してはほんとに同じつくりなので割愛。
+page.server.ts ではユーザーデータを取ってきて、 bcrypt でパスワードのチェックを行ない、
トークン情報の更新と、cookie にデータを書き込むことをしている。
認証
ユーザー登録画面とログイン画面ではログイン済みかのチェックは必要ないけど、
それ以外の画面ではログイン済みであることが重要になってくる。
ただ、全てのページに認証済みかのチェックの処理を書いていくのはめんどくさいので、
共通で読み込まれる部分に書くことで全てのページに適用する。
例えばroutes直下に作れば全てのページに影響するけど、
routes/login直下に作ればlogin以下のページとサブディレクトリにだけ影響するって感じっぽい。
今回使った load はページ処理呼び出し前に叩かれるっぽい。
code:+layout.server.ts
import type {LayoutServerLoad} from "./$types";
import {redirect} from "@sveltejs/kit";
export const load: LayoutServerLoad = async ({url, cookies}) => {
const session = await cookies.get("session")
if (!session && (url.pathname !== "/login" && url.pathname !== "/register")) {
throw redirect(303, "/login")
}
}
スレッド投稿画面
ひとまずスレッドを投稿するためのページの追加
code:post/+page.svelte
<script lang="ts">
import type {ActionData} from "./$types";
export let form: ActionData
</script>
<h1>スレッド投稿</h1>
{#if form?.message}
<p class="error">{form?.message}</p>
{/if}
<form method="post" action="?/post">
<label>内容:
<input name="content" type="test">
</label>
<button>投稿する</button>
</form>
<style>
.error {
color: red;
}
</style>
次に記事内では共通処理の共通化について書いてるけど、
早すぎる共通化は人類の敵!ってことで、いったん +page.server.ts を書いていっちゃう。
記事を無視して申し訳ない!!
code:post/+page.server.ts
import type {Actions} from "./$types";
import {fail, redirect} from "@sveltejs/kit";
import {db} from "$lib/prisma";
export const actions: Actions = {
post: async ({request, cookies}) => {
const data = await request.formData()
const content = data.get("content")
if (typeof content !== "string" || !content) {
return fail(400, {message: "内容は必須です"})
}
const session = cookies.get("session")
const user = await db.user.findUnique({
where: {authToken: session},
select: {id: true, name: true}
})
if (!user) {
throw redirect(303, "/login")
}
await db.post.create({
data: {
userId: user.id,
content: content
}
})
throw redirect(303, "/")
}
}
実際に動作テストして確認
$ sqlite3 board-app/prisma/dev.db
SQLite version 3.42.0 2023-05-16 12:36:15
Enter ".help" for usage hints.
sqlite> select * from post;
1|d5376b57-2e10-4b3f-9764-4b5ac6d1e5a3|aaaa|1688533602454
sqlite> .exit
共通化については必要になったところで改めてやりましょ。
スレッド一覧画面
一覧では、それぞれのスレッドをコンポーネントを使って表示してみたいと思います。
お、SPAと言えばコンポーネント指向!ってイメージがあって、それっぽいぞ!
って思ったけど、SvelteKitでSSRしてるんやし、SPAじゃないね。
とりあえずコンポーネントは lib にいれるっぽい。
直感に反しててちょっと意外な感じがするけど、とりあえず従っておく
code:lib/Thread.svelte
<script lang="ts">
export let id: number;
export let content: string;
</script>
<div class="thread">
<div class="thread-content">
<h2>{content}</h2>
<a href="/detail/{id}">スレッドの詳細を見る</a>
</div>
</div>
<style>
.thread {
display: flex;
flex-direction: row;
width: 100%;
margin: 10px auto;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
}
.thread-content {
width: 100%;
margin-left: 10px;
}
.thread-content a {
display: inline-block;
font-size: 14px;
color: blue;
text-decoration: none;
}
</style>
んで、サーバサイド
code:+page.server.ts
import type {PageServerLoad} from "./$types";
import {db} from "$lib/prisma";
export const load: PageServerLoad = async () => {
const threads = await db.post.findMany({
orderBy: {id: "desc"}
})
return {threads: threads}
}
これまでサーバサイドは Actions を使ってきた。
これはPOSTされた値をハンドリングすることを目的としていたためで、
つまりはページ描画後に行なわれる動作だったためらしい。
今回はページ描画前にスレッドの一覧をDBから取り出しておく必要があるため、
PageServerLoad を利用するとのこと。
Threadコンポーネントを利用する+page.svelteも作る。
ルートの+page.svelteは初期状態で作られているので、これを書き換える。
code:+page.svelte
<script lang="ts">
import {goto} from "$app/navigation"
import type {PageData} from "./$types";
import Thread from "$lib/thread.svelte"
export let data: PageData
</script>
<main>
<h1>スレッド一覧</h1>
<button type="button" on:click={() => goto("/post")}>投稿する</button>
{#each data.threads as thread}
<Thread id={thread.id} content={thread.content}></Thread>
{/each}
</main>
スレッド詳細画面
今までやってきたことの振り返りみたいな感じですね。
おっけー。
とかいいつつ、共通化を後回しにしたまま振り返りが来てしまったヤバイ。
共通化については、データ登録時に必要になる情報なので、
スレッドの詳細、コメントの一覧の作る段階ではまだ必要ない。
というわけで、いつも通りサーバ側から。
schema.prisma である程度定義していたから勝手にjoinしてくれると思ってたけど、
そこまで便利ではないみたいで、どういう条件でjoinして取り出すかは明記してあげないといけないっぽい。
何度もselect文投げてもいいけど、まとめて取れるならそれが良いってことで記事通りに取得してる。
code:detail/postId/+page.server.ts import type {PageServerLoad} from "./$types";
import {db} from "$lib/prisma";
import {error} from "@sveltejs/kit";
export const load: PageServerLoad = async ({params}) => {
const thread = await db.post.findUnique({
where: {id: Number(params.postId)},
include: {
Comment: {
orderBy: {id: "desc"},
select: {
content: true,
created_at: true,
user: {select: {name: true}}
}
},
user: {select: {name: true}}
}
})
if (!thread) throw error(404, {message: "存在しないスレッドです"})
return {thread: thread}
}
そして表示側。
記事の通りだと threadAuthor がログインしているユーザーっぽくなってるので、
記事の作者の情報を引っ張ってくるように微調整してる。
code:detail/postId/+page.svelte <script lang="ts">
import type {PageData} from "./$types"
type comment = {
user: {name: string}
content: string
created_at: Date
}
export let data: PageData
const threadAuthor: string = data.thread.user.name
const threadContent: string = data.thread.content
const threadCreatedAt: Date = data.thread.created_at
const comments: comment[] = data.thread.Comment
</script>
<div>
<a href="/">一覧に戻る</a>
</div>
<h1>スレッド詳細</h1>
<h2>{threadContent}</h2>
<p>作成者: {threadAuthor} 作成日時: {threadCreatedAt}</p>
<h2>コメント</h2>
{#if comments.length}
{#each comments as comment}
<p>名前: {comment.user.name}</p>
<p>日時: {comment.created_at}</p>
<p>コメント: {comment.content}</p>
<br>
{/each}
{:else}
<p>コメントはありません</p>
{/if}
次にコメントを追加するためのformを追加。
追加先は先ほど追加した詳細画面である +page.svelte のscriptと末尾に追加。
これでコメントを追加するためのformができた。
code:detail/postId/+page.svelte <script>
// 省略
export let form: ActionData
</script>
// 詳細を表示するためのHTML
<div>
{#if form?.message}
<p class="error">{form.message}</p>
{/if}
</div>
<form method="post" action="?/comment">
<input name="comment" type="text">
<button type="submit">コメントする</button>
</form>
<style>
.error {
color: red;
}
</style>
次にコメント投稿のバックエンドを作るけど、共通化はまだせずに必要な処理を全部書いてみる。
って言っても、スレッド投稿と似た処理にはなる。
code:detail/postId/+page.server.ts // importにActionsを追加
import type {Actions, PageServerLoad} from "./$types";
// import, load関連を省略
export const actions: Actions = {
comment: async ({request, cookies, params}) => {
const data = await request.formData()
const comment = data.get("comment")
if (typeof comment !== "string" || !comment) {
return fail(400, {message: "コメントは必須です"})
}
const session = cookies.get("session")
const user = await db.user.findUnique({
where: {authToken: session},
select: {id: true, name: true}
})
if (!user) {
throw redirect(303, "/login")
}
await db.comment.create({
data: {
userId: user.id,
postId: Number(params.postId),
content: comment
}
})
}
}
actionsの最後にredirectがない場合は自分自身を再度描画して表示するっぽい。
よっし!これでコメントの投稿も出来るようになった!!
共通化
スレッド投稿とコメント投稿の両方で、ログイン中のユーザーの情報を取り出すという共通の処理がある。
まあこれくらい当たり前の機能であれば書き換えは少ないと思うけど、
もし何かの拍子に書き換えが必要になったとき、複数のファイルに同じ変更をする必要が発生する。
今は2ファイルだからいいものの、これが数十ファイル、数百ファイルとなってくるとやってられないし絶対ミスる。
ってことで、共通の処理は共通で実行されるどこかに追い出そう!
code:src/hooks.server.ts
import type {Handle} from "@sveltejs/kit";
import {db} from "$lib/prisma";
export const handle: Handle = async ({event, resolve}) => {
const session = event.cookies.get("session")
if (!session) {
return resolve(event)
}
const user = await db.user.findUnique({
where: {authToken: session},
select: {id: true, name: true}
})
if (user) {
event.locals.user = {
id: user.id,
name: user.name
}
}
return resolve(event)
}
イベントからcookieを取り出して、ユーザー検索して、対象ユーザーがいたらlocalsにuser情報を保持。
ただこれだけだとlocals.userの定義が分からないってことで、定義していると思われるファイルに変更を加える。
code:src/app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user: {
id: string,
name: string,
}
}
// interface PageData {}
// interface Platform {}
}
}
export {};
interface Locals に定義を記入したことで、解決してくれるようになったはず。
この共通化によって、各処理でユーザー情報を独自にとる必要がなくなり、
Actionsの処理に入る際にはログイン済みならuser情報がlocalsに入ってる状況で呼び出されるようになった。
ORMをつかってるとは言え、何度もDBへの命令をかくのは面倒なので助かる!
routes/register/+page.server.ts と routes/detail/[postId]/+page.server.ts では、
cookieからユーザーを特定する処理を書いていたので、書き換えておく。
変更内容は同じなので、 routes/register/+page.server.ts だけ。
code:register/+page.server.ts
// cookiesをもらっていたところをlocalsに変更する
// post: async ({request, cookies}) => {
post: async ({request, locals}) => {
// sessionからユーザーを取得していたところを、localsから貰うようにする
// const session = cookies.get("session")
// const user = await db.user.findUnique({
// where: {authToken: session},
// select: {id: true, name: true}
// })
const user = locals.user
あとはサーバ側の共通処理である +layout.server.ts を修正
code:+layout.server.ts
import type {LayoutServerLoad} from "./$types";
import {redirect} from "@sveltejs/kit";
export const load: LayoutServerLoad = async ({url, locals}) => {
if (!locals.user && (url.pathname !== "/login" && url.pathname !== "/register")) {
throw redirect(303, "/login")
}
return {user: locals.user}
}
変更点としては、cookieに依存していたところをlocalsに変え、
cookiesの値があればOKだったところを、userデータがあることを条件にした。
更に、ユーザーデータを常に返すように変更した
楽になった!
ログアウト機能
cookie情報消すだけのお話かなーとおもったら、共通ヘッダーを作るっぽい。
サーバサイドを作るとして、 /logout というページは表示することがないので、
load の時点でリダイレクトしてしまう。
code:logout/+page.server.ts
import type {Actions, PageServerLoad} from "./$types";
import {redirect} from "@sveltejs/kit";
export const load: PageServerLoad = async () => {
throw redirect(303, "/")
}
export const actions: Actions = {
logout: async ({cookies}) => {
cookies.delete("session")
throw redirect(303, "/login")
}
}
共通ヘッダーはサーバ側の共通と同じく +layout で作る。
code:+layout.svelte
<script lang="ts">
import {page} from "$app/stores"
</script>
{#if !$page.data.user}
<a href="/login">ログイン</a>
<a href="/register">新規登録</a>
{:else}
<form action="/logout?/logout" method="POST">
<button type="submit">ログアウト</button>
</form>
{/if}
<slot />
$page.data.user はログイン済みなら持ってる情報っぽい?
hooks.server.ts で実装した、locals に持ってるユーザ情報を持ってこれるっぽい。
あと最後にある <slot /> によって、共通ヘッダの下に必要な要素が描画される。
slotを省略するとヘッダーだけになってしまう。
記事の通りだと、この時点で $page.data.user に値が入っていることはないはずなので、
if分岐は常に条件を満たしててログインしか表示されないはず。
エラー画面
SvelteKitでは簡単にエラー時に特定のエラー画面に飛ばすことができます
カスタムエラーページ良い!
code:detail/postId/+error.svelte <script lang="ts">
import {page} from "$app/stores"
</script>
<h1>エラーページ</h1>
<h3>{$page.error?.message}</h3>
パスから一番近いエラーページが表示されるみたいなので、
ルート直下にひとつ置いておくと安心すね。
おわりに
記事の通りにやって詰まったらちょっと調べて~って感じでざっとSvelteKitを触れてよかった。
記事のこの部分間違ってるんじゃない?程度には理解が進んだし、
簡単なページを作るのには困らなさそう!!
ただ、SPAのイメージが強いSvelteの良いところを活かせてない気がしてなんとも。
サーバサイドとの高頻度の疎通なんてのも出来たらいいのにな~とぼんやり考えてる。
チャットルームとか分かりやすくていいかも?
ぐぐったら何か出てきそうやし、ちょっと考えてみよう。
参考
更新履歴